uart@10000000 {
interrupts = <0x0a>;
interrupt-parent = <0x02>;
clock-frequency = <0x384000>;
reg = <0x00 0x10000000 0x00 0x100>;
compatible = "ns16550a";
};RISC-V from scratch 2_编译器option push和option pop-CSDN博客RISC-V from scratch 3: 写 UART 驱动_risc-v uart驱动-CSDN博客RISC-V from scratch 4: 写 UART 驱动_16550a编程-CSDN博客 可以参考上面的两个博客
实现:
int UART_NS16550::put_char(u8 c)
{
//UART 中寄存器 LSR 的 bit 5 是用来指示 THR 寄存器是否为空
while( !( readRegister( LSR ) & 0x20 ) ); // 等待发送缓冲区空
writeRegister( THR, c ); // 发送字符
return 0;
}
u8 UART_NS16550::get_char()
{
u8 c;
while( !( readRegister( LSR ) & 0x01 ) ); // 等待接收缓冲区非空
c = readRegister( RHR ); // 读取接收缓冲区
if( c == 0x0D ) // 如果是回车符
{
put_char( 0x0A ); // 发送换行符
}
return c;
}这里使用了while来循环等待缓冲区,浪费了许多cpu资源,实际上以后实现了中断之后可以进行改进。
裸机编程
不依赖操作系统,直接在硬件上运行。
- C++ 程序:实现业务逻辑(如通过 UART 收发数据)。
- 链接脚本(.ld):定义内存布局(代码、数据、栈的地址分配)。
- 启动代码(crt0.S):初始化硬件环境(设置栈指针、清零
.bss段等),最后跳转到main函数。 所有的内存操作、串口通信均直接通过直接读写硬件寄存器完成。
crt0.S的代码
.section .text.init
.globl _start
_start:
la sp, _stack_top #必须与链接脚本中的_stack_top符号一致
call main
1: j 1b # 停机循环这是放在init段里面的,在链接脚本处一定要处理这个段.启动程序是从virt机器是首地址开始的,所以一定需要放在起始地址0x80000000 crt0其实就是boot程序,启动时处于机器模式。
问题:打印字符串时出现这个问题:
/tmp/ccw0sKNs.o: in function `.L0 ':
/home/czc/uart_16550/main.cc:7:(.text+0x44): relocation truncated to fit: R_RISCV_HI20 against `.LC0'写入链接脚本注意的是virt机器的基地址是 uart.init((void*)0x10000000); // QEMU RISC-V virt 机器的 UART 地址 链接脚本中,ROM 的起始地址是 0x80000000,而 .text 和 .rodata 被强制放置在此地址:
MEMORY {
ROM (rx) : ORIGIN = 0x80000000, LENGTH = 256K /* 高位地址 */
RAM (rwx) : ORIGIN = 0x80040000, LENGTH = 256K
}
SECTIONS {
. = 0x80000000; /* 起始地址为 0x80000000 */
.text : { ... } >ROM
.rodata : { ... } >ROM
}- 如果你未在编译命令中显式指定
-mcmodel=medany,编译器会默认使用 medlow模型。 medlow模型要求所有符号地址必须位于 32位有符号整数范围 内(即0x7FFFFFFF以下)。- 但你的
.rodata段地址是0x80000000(超过0x7FFFFFFF),直接违反medlow模型约束。 - 当编译器生成访问
.LC0的指令时,会使用lui指令加载地址的高20位。 - 如果
.LC0的地址是0x80000000,其高20位为0x80000(二进制1000 0000 0000 0000 0000),但lui的立即数字段 只能表示有符号的20(范围-524288到524287)。 0x80000对应的有符号值是-524288,但链接器发现无法正确截断地址,因此报错:relocation truncated to fit: R_RISCV_HI20。
那么为什么我一直调用put_char并不会报错,传入字符串调用put_string就会报错呢?
当你在代码中直接使用字符串字面量(例如 uart.put_string("\nHello, World!\n"))时:
- 编译器会将整个字符串 作为只读数据 存储在
.rodata段(Read-Only Data Segment)。 - 由于你的链接脚本强制将
.rodata段放在高位地址(例如0x80000000),访问该字符串需要通过lui指令加载其高20位地址。 当你逐个调用uart.put_char('H')、uart.put_char('e')等时: - 每个字符(如
'H')会被编译器视为 立即数(ASCII 码值0x48),直接嵌入到指令中,无需存储到内存。
机器模式启动
将crt0.s直接改名为boot.s
mhartid 寄存器
考虑到 QEMU virt 机器可以使用多个处理器,那么我们就需要防止多个 hart 执行 boot.s ,在机器刚开始运行(以及我们刚开始编写代码时),一哄而上可不是什么好的选择。
_start:
# read our hart identifier into t0
# see if it is 0, if not to busy loop
csrr t0, mhartid
bnez t0, 4f
...
4:
wfi
j 4b因此这里我们首先使用 mhartid 寄存器获取 hart 的 ID ,csrr 是一个伪指令,它读取一个 CSR 寄存器。让非 0 的 hart 全部跳转到死循环里,并将它们 stall 住,死循环中 wfi 指令在这方面是专家:
The Wait for Interrupt instruction (WFI) provides a hint to the implementation that the current hart can be stalled until an interrupt might need servicing.
satp 寄存器
现在,我们就要开始设置一些寄存器了以及一些初始化任务。
我们需要取消内存分页机制,这样我们就可以完全控制 MMU (Memory Management Unit) ,只不过控制的方式就是让 virtual memory = physical memory 。参考 RISC-V 特权架构 和 RISC-V 中文手册,使用 csrw 语句,向 satp 寄存器写 0 即可。
# SATP should be 0
# Supervisor Address Translation and Protection
csrw satp, zeromstatus 寄存器
刚开始执行代码一定是机器模式,但是我们总不能一直让 hart 在机器模式下运行;此外,全局中断使能位也需要我们控制。这些都可以在 mstatus 寄存器上找到,关于 mstatus 寄存器,RISC-V 特权架构 和 RISC-V 中文手册上都有详细介绍。在此就略写几句。
当进入 main 函数时,hart 最好要进入监管者模式。因为 main 函数事实上是我们操作系统内核最主要的函数之一,此外,我们也希望中断能被打开。对照 mstatus 寄存器的位图,我们可以在对应位域置 1 ,来打开中断或者记录信息等。

比如,我们想先打开机器模式的中断使能,那么我们需要:
将 mstatus.MIE 位置为 1 ,因为它代表机器模式全局下的中断使能 将 mstatus.MPIE 位置为 1 ,它代表了在中断/异常发生前,机器模式全局下的中断使能(我们肯定不想在中断/异常发生一次后,使能就失效了吧) 我们还要将 mstatus.MPP 位置为 01,它代表了中断/异常发生前,代码运行的模式。之所以置为 01(监管者模式),是为了在执行 mret 的时候进入监管者模式。结合之前所说的,写下如下代码:
li t0, (0b01 << 11) | (1 << 7) | (1 << 3)
csrw mstatus, t0初始化 BSS 数据段
如果你了解了 C 语言内存分布,你就会知道全局未初始化变量都会放在 BSS 段中,即我们在链接器文件里描述的 .bss section 。这里不得不说一句,写 C/C++ 未在定义时初始化是非常危险的😅,因为这会导致不确定行为。那么,作为操作系统的开发人员,初始化 BSS 数据段的责任就担在我们身上了。
还记得我们之前定义的 __bss_start 和 __bss_end 吧,它们一个在 .bss 数据段前面,一个在后面,这两个符号是为方便数据初始化而设定的,那么目前,我们先把 .bss 段全部初始化为 0 。
la a0, __bss_start
la a1, __bss_end
bgeu a0, a1, 2f
l1:
sd zero, (a0)
addi a0, a0, 8
bltu a0, a1, l1mie 寄存器
mie 寄存器包含了中断使能位,用于控制中断是否有效。其位域如下图:
我们除了要打开机器模式下的全局中断使能,还需要打开软件、时钟、外部这三部分子中断使能,参照 mie 寄存器的位域图,我们可以写出下面的代码,打开所有机器模式下的中断使能。
li t3, (1 << 3) | (1 << 7) | (1 << 11)
csrw mie, t3mtvec 寄存器
mtvec 寄存器又是什么?该寄存器全名 Machine Trap-Vector Base-Address Register,它存放了 trap vector 信息,包括了基地址和模式位。换句话说,当中断/异常发生时,PC 值肯定需要跳转到中断/异常处理程序,该寄存器就保存了这些处理程序的地址。对 mtvec 寄存器的详细介绍,还是要参考 RISC-V 特权架构 和 RISC-V 中文手册 和 RISC-V privileged manual 资料。
我们先不考虑中断/异常处理程序,先定义一个符号 mtrap_vector ,把它当作处理程序的开始点,然后,把它放入到 mtvec 寄存器中。
la t2, mtrap_vector
csrw mtvec, t2转到 main
看起来我们快要写完了。到这时候,大家可能会变得不耐烦且急躁。于是,写出了最后一句指令 mret 。
哦不,等等,mret 指令会把我们带到哪里?回顾一下 crt0.s ,在快结束的时候,我们使用指令 jal zero, main 跳转到了 main 函数里,我们的 boot.s 当然也需要跳转到 main 函数。但是,我们还可以用 jal zero, main 指令跳转吗?不行。这样跳转的话,我们仍在机器模式下。为了使 hart 跑在监管者模式下,我们必须使用 mret 。
所以,mret 指令会把我们带到哪里?参考 RISC-V 的相关资料,在处理 mret 指令时,PC 值会从 mepc 寄存器取得。因此,我们必须将 main 函数的地址存入 mepc 寄存器。
la t1, main
csrw mepc, t1
...
mret